package jam4j.lang;

import jam4j.Globals;
import jam4j.build.TimeStamp;

import java.io.File;
import java.io.Serializable;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A path as Jam4J understands it. A path consists of six parts, all optional:
 * 
 * <ul>
 * <li>The {@link #grist}, between angle brackets; this is discarded from a
 * target's name when binding it to a file.
 * <li>The {@link #root}, which is always parsed as empty but can be set by the
 * {@code R} modifier in a reference.
 * <li>The {@link #dir}ectory, being everything up to the final path separator.
 * <li>The {@link #base}, being everything after the directory, up to but not
 * including the final period.
 * <li>The {@link #suffix}, being the file extension (including the period).
 * <li>The {@link #member}, between parentheses, being the filename of a member
 * within an archive.
 * </ul>
 * 
 * @author Luke Maurer
 */
public final class FilePath implements Serializable, Cloneable {
    private static final char PATH_DELIM = java.io.File.separatorChar;
    private static final String PATH_DELIM_STR = java.io.File.separator;
    
    public String grist, root, dir, base, suffix, member;
    
    /**
     * Construct a path from its six constituent parts. See {@link FilePath}
     * for argument descriptions. Each argument can be null (which will be
     * equivalent to an empty string).
     */
    public FilePath(String grist, String root, String dir, 
            String base, String suffix, String member) {
        this.grist  = grist  == null ? "" : grist;
        this.root   = root   == null ? "" : root;
        this.dir    = dir    == null ? "" : dir;
        this.base   = base   == null ? "" : base;
        this.suffix = suffix == null ? "" : suffix;
        this.member = member == null ? "" : member;
    }
    
    private FilePath(String[] parts) {
        this(parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]);
    }
    
    @Override
    public FilePath clone() {
        try {
            return (FilePath) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(e);
        }
    }
    
    private String[] parts() {
        return new String[] { grist, root, dir, base, suffix, member };
    }
    
    /**
     * Transform this path into the path of the parent directory (as selected
     * by the {@code P} reference modifier) by chopping off everything after
     * the final path separator. Note that if the path is already a directory
     * terminated by a path separator, it will not change. (Strange semantics,
     * I know. Blame Perforce :-) )
     */
    public void parent() {
        base = suffix = member = "";
    }
    
    private static final Pattern 
        GRIST_PATTERN = Pattern.compile("^<(.*?)>"),
        MEMBER_PATTERN = Pattern.compile("\\((.*?)\\)$");
    
    /**
     * Parse a string as a file path.
     * 
     * @param string The string to parse.
     * @return The file path represented by the string. Note that {@link #root}
     * will always be empty in the returned path.
     */
    public static FilePath parse(final String string) {
        int startIx = 0, endIx = string.length();
        
        final String grist;
        {
            final Matcher matcher = GRIST_PATTERN.matcher(string);
            if (matcher.find()) {
                grist = matcher.group(1);
                startIx = matcher.end();
            } else
                grist = null;           
        }
        
        final String dir;
        {
            final int pathDelimIx = string.lastIndexOf(PATH_DELIM);
            if (pathDelimIx >= startIx) {
                dir = pathDelimIx == startIx ? 
                        PATH_DELIM_STR : string.substring(startIx, pathDelimIx);
                
                startIx = pathDelimIx + 1;
            } else 
                dir = null;
        }
        
        final String member;
        {
            final Matcher matcher =
                MEMBER_PATTERN.matcher(string).region(startIx, endIx);
            if (matcher.find()) {
                member = matcher.group(1);
                endIx = matcher.start();
            } else
                member = null;
        }
        
        final String suffix, base;
        {
            final int dotIx = string.lastIndexOf('.', endIx);
            if (dotIx < startIx) {
                base = string.substring(startIx, endIx);
                suffix = null;
            } else {
                base = string.substring(startIx, dotIx);
                suffix = string.substring(dotIx, endIx);
            }
        }
        
        return new FilePath(grist, null, dir, base, suffix, member);
    }
    
    /**
     * Return the string form of the path. If this path was constructed by a
     * call to {@link #parse(String)}, the returned string should be equal to
     * the parsed string.
     */
    @Override
    public String toString() {
        return toString(true);
    }
    
    /**
     * Return the string form of the path, optionally excluding the
     * {@link #grist} and {@link #member} parts so as to provide an actual
     * customary path suitable for external use.
     * 
     * @param withGristAndMember Whether to incnlude the grist and member.
     * @return The string form, minus the initial grist and terminating member
     * specification if {@code withGristAndMember} is false.
     * 
     * @see jam4j.build.Target#bind(Context)
     */
    public String toString(boolean withGristAndMember) {
        final StringBuilder builder = new StringBuilder();
        
        if (withGristAndMember && !grist.isEmpty())
            builder.append('<').append(grist).append('>');
        
        if (!root.isEmpty() &&
                !".".equals(root) &&
                !dir.startsWith(PATH_DELIM_STR))
            builder.append(root).append(PATH_DELIM);
        
        
        if (!dir.isEmpty()) {
            builder.append(dir);
        
            if (!PATH_DELIM_STR.equals(dir) && 
                    (!base.isEmpty() || !suffix.isEmpty()))
                builder.append(PATH_DELIM);
        }
        
        builder.append(base).append(suffix);
        
        if (withGristAndMember && !member.isEmpty())
            builder.append('(').append(member).append(')');
        
        return builder.toString();
    }
    
    /**
     * @return This path, as an absolute {@link File} object. If this is a
     * relative path, it will be resolved relative to {@link Globals#cwd()}.
     * 
     * @see #absoluteFile(File)
     */
    public File toAbsoluteFile() {
        final String filename = toString(false);
        File file = new File(filename);
        return file.isAbsolute() ? file : new File(Globals.cwd(), filename);
    }
    
    /**
     * Return the given File, resolved against the current working directory
     * as returned by {@link Globals#cwd()} if it is relative.
     * <p>
     * Note that this must be used instead of {@link File#getAbsoluteFile()}
     * because Jam4J never relies on the JVM's notion of the working directory
     * (as set in the {@code user.dir} system property).
     * 
     * @param file The file to make absolute.
     * @return The given file, with {@link Globals#cwd()} prepended unless it
     * is already absolute.
     */
    public static File absoluteFile(File file) {
        return file.isAbsolute() ?
                file : new File(Globals.cwd(), file.getPath());
    }
    
    /**
     * Return the absolute File object for the given path, resolved against the
     * current working directory as returned by {@link Globals#cwd()} if it is
     * relative.
     * <p>
     * Note that this must be used instead of {@link File#File(String)}
     * because Jam4J never relies on the JVM's notion of the working directory
     * (as set in the {@code user.dir} system property).
     * 
     * @param filename The filename to parse as a file.
     * @return The File object for the given path, as prepended by
     * {@link Globals#cwd()} if it is a relative path.
     */
    public static File absoluteFile(String filename) {
        return absoluteFile(new File(filename));
    }
    
    /**
     * Return the absolute path for the given path, resolved against the
     * current working directory as returned by {@link Globals#cwd()} if it is
     * relative.
     * 
     * @param filename The filename to make absolute.
     * @return The given filename, with {@link Globals#cwd()} prepended unless
     * it is already absolute.
     */
    public static String absoluteFilename(String filename) {
        return absoluteFile(filename).getAbsolutePath();
    }
    
    /**
     * @return The timestamp for the given file, consisting of its name and
     * its modified date. If the file does not in fact exist, the date will be
     * {@code new Date(0)}.
     * 
     * @see TimeStamp
     * @see File#lastModified()
     */
    public TimeStamp timeStamp() {
        final File file = toAbsoluteFile();
        return new TimeStamp(toString(), new Date(file.lastModified()));
    }
    
    /**
     * Any of the six parts of a Jam path.
     * 
     * @author Luke Maurer
     * 
     * @see FilePath#get(Part)
     * @see FilePath#set(Part, String)
     * @see FilePath#modify(Map, Context)
     * @see FilePath#select(EnumSet)
     */
    public enum Part {
        GRIST, ROOT, DIR, BASE, SUFFIX, MEMBER;

        public static Part byLetter(char c) {
            switch (c) {
                case 'G': return GRIST;
                case 'R': return ROOT;
                case 'D': return DIR;
                case 'B': return BASE;
                case 'S': return SUFFIX;
                case 'M': return MEMBER;
                default:  return null;
            }
        }
    }
    
    /**
     * Get the specified part of this path.
     * 
     * @param part The part to get.
     * @return The given part of this path.
     */
    public String get(Part part) {
        switch (part) {
            case GRIST:  return grist;
            case ROOT:   return root;
            case DIR:    return dir;
            case BASE:   return base;
            case SUFFIX: return suffix;
            case MEMBER: return member;
            default:     return toString();
        }
    }
    
    /**
     * Set the specified part of this path to the given value.
     * 
     * @param part The part to set.
     * @param value The value to set it to.
     */
    public void set(Part part, String value) {
        switch (part) {
            case GRIST:  grist  = value; break;
            case ROOT:   root   = value; break;
            case DIR:    dir    = value; break;
            case BASE:   base   = value; break;
            case SUFFIX: suffix = value; break;
            case MEMBER: member = value; break;
        }
    }
    
    /**
     * Modify this path according to the given map of parts to expressions,
     * returning the results as a new array of new FilePath objects. Note that
     * if any of the expressions resolve to multiple values, multiple paths
     * will result as per the usual rules of Jam string multiplication.
     * 
     * @param map A map of parts to change to expressions which resolve to the
     * values to change them to.
     * @param cxt The context in which to resolve the expressions.
     * @return An array of the resulting paths.
     */
    // TODO This shouldn't be taking a Context object; that's way out of scope
    // for this class.
    public FilePath[] modify(Map<Part, Expression> map, Context cxt) {
        if (map == null || map.isEmpty())
            return new FilePath[] { this };
        
        List<String[]> specs = new ArrayList<String[]>();
        specs.add(parts());
        
        for (Map.Entry<Part, Expression> entry : map.entrySet()) {
            final Part part = entry.getKey();
            final String[] values = entry.getValue().resolve(cxt);
            
            if (values.length == 1) {
                for (String[] parts : specs)
                    parts[part.ordinal()] = values[0];
            } else {
                final List<String[]> newSpecs = new ArrayList<String[]>();
            
                for (String[] spec : specs)
                    for (String value : values) {
                        final String[] newSpec = spec.clone();
                        newSpec[part.ordinal()] = value;
                        newSpecs.add(newSpec);
                }
                
                specs = newSpecs;
            }
        }
        
        final FilePath[] ans = new FilePath[specs.size()];
        int ix = 0;
        for (String[] parts : specs)
            ans[ix++] = new FilePath(parts);
        
        return ans;
    }
    
    /**
     * Retain only the given parts of this path. All others will be set to the
     * empty string.
     * 
     * @param parts The parts to keep.
     */
    public void select(EnumSet<Part> parts) {
        if (parts == null || parts.isEmpty())
            return;
        
        for (Part part : EnumSet.complementOf(parts))
            set(part, "");
    }
}
